Skip to content

Improve live activity routing and diagnostics#3685

Merged
juliusmarminge merged 26 commits into
mainfrom
t3code/live-activities-improvements
Jul 7, 2026
Merged

Improve live activity routing and diagnostics#3685
juliusmarminge merged 26 commits into
mainfrom
t3code/live-activities-improvements

Conversation

@juliusmarminge

@juliusmarminge juliusmarminge commented Jul 4, 2026

Copy link
Copy Markdown
Member

Summary

  • Adds relay-aware APNs routing for mobile registrations, including bundle ID and sandbox vs production environment detection.
  • Improves Live Activity reconciliation by re-registering tokens on foreground and cleaning up orphaned activities on sign-out.
  • Reworks the Agent Activity widget to surface per-row status, safer deep links, and better overflow handling.
  • Adds a new Push Delivery diagnostics section in Settings so users can inspect relay registration and last delivery state.
  • Expands relay-side device diagnostics, APNs delivery tracking, and persistence to improve observability and routing accuracy.

Testing

  • vp check
  • vp run typecheck
  • vp test
  • Added/updated tests for mobile diagnostics, remote registration, widget rendering, and relay APNs delivery/device tracking.
  • Not run: manual device or APNs end-to-end verification

Note

High Risk
Touches relay registration, APNs routing, cloud link modes, and Live Activity lifecycle (including removing push-to-start); misconfiguration or partial deploy could break mobile notifications or mis-route pushes.

Overview
Improves mobile agent awareness end-to-end: relay device registration now sends bundle ID and sandbox vs production APNs routing, tracks whether the relay actually accepted registration (settings toggles and alerts follow that, not just iOS permission), skips duplicate registrations via a persisted signature, and re-registers Live Activity tokens on foreground with short-window deduping. Push-to-start registration is removed in favor of arming a card when the user sends a task (and priming from a relay activity snapshot when idle), plus cleanup on cloud sign-out and a releaseAgentAwarenessRelayTokenProvider path so auth UI remounts do not wipe registration or end activities.

Agent Activity Live Activity UI is reworked (attention-first rows, phase tints aligned with web, deep links, branded T3 mark via new Expo config plugins and actool shell phase). expo-widgets gains frequentUpdates for higher update budget.

T3 Connect vs publishing split: environments can link in publish_only mode (manual endpoint, no Cloudflare tunnel) via CLI (connect link --publish-only, connect publish) and web settings (separate toggles reconciled together). Link proofs accept manual providers with activity-only scopes; server publish path defers transient tombstones and first completed snapshots before publishing.

Relay DB adds bundle_id and aps_environment on mobile devices (migration in diff).

Reviewed by Cursor Bugbot for commit 6f0b32d. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Add per-device APNs routing, JWT token caching, and publish-only link mode to Live Activity delivery

  • Stores bundle_id and aps_environment per device in the relay and uses them to route APNs pushes to the correct topic and environment per device rather than using global config.
  • Introduces ApnsProviderTokens service that caches APNs JWTs quantized to 45-minute windows, preventing TooManyProviderTokenUpdates errors; uses deterministic ES256 signing via @noble/curves.
  • Overhauls Live Activity delivery: activities are no longer remotely started; ends are suppressed within a grace window after arming; attention-phase transitions and newly-terminal rows can now trigger alert payloads with haptics; stale terminal aggregates suppress re-alerting.
  • Adds a publish_only cloud link mode in the CLI (t3 connect publish) and web settings UI, allowing agent activity publishing without a managed Cloudflare tunnel; linking with this mode uses providerKind: "manual" and deprovisions any existing tunnel allocation.
  • Reworks the AgentActivity widget to sort rows attention-first, apply per-phase tints from the web palette, deep-link to the most relevant thread, show a branded logo, and display a phase glyph in minimal presentation.
  • Exposes relay registration status (unknown/pending/registered/failed) to the settings UI so notification and Live Activity toggles only appear enabled when the device is actually registered.
  • Risk: makeAggregateState now requires a nowMs parameter and statusForPhase('starting') returns 'Connecting' instead of 'Starting', which are breaking changes for callers.

Macroscope summarized 6f0b32d.

@coderabbitai

coderabbitai Bot commented Jul 4, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: fe216c9a-1cd9-460a-a80d-6d90748a00b5

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch t3code/live-activities-improvements

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added size:XL 500-999 changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. labels Jul 4, 2026

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for all 3 issues found in the latest run.

  • ✅ Fixed: Missing database migration columns
    • Added the drizzle-kit-generated Postgres migration (migration.sql + snapshot.json) adding bundle_id and aps_environment to relay_mobile_devices, chained onto the previous snapshot.
  • ✅ Fixed: Last delivery ignores HTTP status
    • The Last Delivery row now treats a non-2xx lastDeliveryStatus as a failure even when lastDeliveryError is null, showing "Delivery failed (status)" instead of "Delivered".
  • ✅ Fixed: Diagnostics use global attempt window
    • Replaced the global 100-row window with a DISTINCT ON (device_id) query ordered by created_at DESC so each device's true latest delivery attempt is always returned.

Create PR

Or push these changes by commenting:

@cursor push a97e0b6881
Preview (a97e0b6881)
diff --git a/apps/mobile/src/features/agent-awareness/deviceDiagnostics.test.ts b/apps/mobile/src/features/agent-awareness/deviceDiagnostics.test.ts
--- a/apps/mobile/src/features/agent-awareness/deviceDiagnostics.test.ts
+++ b/apps/mobile/src/features/agent-awareness/deviceDiagnostics.test.ts
@@ -69,6 +69,28 @@
     ]);
   });
 
+  it("treats a non-2xx delivery status without a reason as a failure", () => {
+    const rows = formatDeviceDiagnosticsRows(
+      makeDevice({
+        bundleId: "com.t3tools.t3code.preview",
+        apsEnvironment: "production",
+        hasPushToken: true,
+        hasPushToStartToken: true,
+        hasLiveActivityToken: true,
+        lastDeliveryAt: "2026-06-05T01:02:59.566Z",
+        lastDeliveryKind: "live_activity_end",
+        lastDeliveryStatus: 400,
+        lastDeliveryError: null,
+      }),
+    );
+
+    expect(rows[4]).toEqual({
+      label: "Last Delivery",
+      value: "Delivery failed (400)",
+      tone: "warn",
+    });
+  });
+
   it("reports healthy registrations with a successful delivery", () => {
     const rows = formatDeviceDiagnosticsRows(
       makeDevice({

diff --git a/apps/mobile/src/features/agent-awareness/deviceDiagnostics.ts b/apps/mobile/src/features/agent-awareness/deviceDiagnostics.ts
--- a/apps/mobile/src/features/agent-awareness/deviceDiagnostics.ts
+++ b/apps/mobile/src/features/agent-awareness/deviceDiagnostics.ts
@@ -73,14 +73,21 @@
     });
   }
 
+  // APNs can fail with a non-2xx status without a reason body, so a null
+  // error alone does not mean the delivery succeeded.
+  const deliveryFailed =
+    diagnostics.lastDeliveryError !== null ||
+    (diagnostics.lastDeliveryStatus !== null &&
+      (diagnostics.lastDeliveryStatus < 200 || diagnostics.lastDeliveryStatus >= 300));
+
   if (diagnostics.lastDeliveryAt === null) {
     rows.push({ label: "Last Delivery", value: "None yet", tone: "muted" });
-  } else if (diagnostics.lastDeliveryError !== null) {
+  } else if (deliveryFailed) {
     const status =
       diagnostics.lastDeliveryStatus === null ? "" : ` (${diagnostics.lastDeliveryStatus})`;
     rows.push({
       label: "Last Delivery",
-      value: `${diagnostics.lastDeliveryError}${status}`,
+      value: `${diagnostics.lastDeliveryError ?? "Delivery failed"}${status}`,
       tone: "warn",
     });
   } else {

diff --git a/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/migration.sql b/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/migration.sql
new file mode 100644
--- /dev/null
+++ b/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/migration.sql
@@ -1,0 +1,3 @@
+ALTER TABLE "relay_mobile_devices" ADD COLUMN "bundle_id" varchar(255);
+--> statement-breakpoint
+ALTER TABLE "relay_mobile_devices" ADD COLUMN "aps_environment" varchar(16);

diff --git a/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/snapshot.json b/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/snapshot.json
new file mode 100644
--- /dev/null
+++ b/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/snapshot.json
@@ -1,0 +1,1479 @@
+{
+  "dialect": "postgres",
+  "id": "e00cc7df-a31e-4b8b-b258-d04e7db7758a",
+  "prevIds": ["385d476b-d4f6-48a3-99e6-0a95af4ee4e4"],
+  "version": "8",
+  "ddl": [
+    {
+      "isRlsEnabled": false,
+      "name": "relay_agent_activity_rows",
+      "entityType": "tables",
+      "schema": "public"
+    },
+    {
+      "isRlsEnabled": false,
+      "name": "relay_delivery_attempts",
+      "entityType": "tables",
+      "schema": "public"
+    },
+    {
+      "isRlsEnabled": false,
+      "name": "relay_dpop_proofs",
+      "entityType": "tables",
+      "schema": "public"
+    },
+    {
+      "isRlsEnabled": false,
+      "name": "relay_environment_credentials",
+      "entityType": "tables",
+      "schema": "public"
+    },
+    {
+      "isRlsEnabled": false,
+      "name": "relay_environment_links",
+      "entityType": "tables",
+      "schema": "public"
+    },
+    {
+      "isRlsEnabled": false,
+      "name": "relay_live_activities",
+      "entityType": "tables",
+      "schema": "public"
+    },
+    {
+      "isRlsEnabled": false,
+      "name": "relay_managed_endpoint_allocations",
+      "entityType": "tables",
+      "schema": "public"
+    },
+    {
+      "isRlsEnabled": false,
+      "name": "relay_mobile_devices",
+      "entityType": "tables",
+      "schema": "public"
+    },
+    {
+      "type": "varchar(191)",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "environment_id",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_agent_activity_rows"
+    },
+    {
+      "type": "text",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "environment_public_key",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_agent_activity_rows"
+    },
+    {
+      "type": "varchar(191)",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "thread_id",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_agent_activity_rows"
+    },
+    {
+      "type": "jsonb",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "state_json",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_agent_activity_rows"
+    },
+    {
+      "type": "varchar(64)",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "updated_at",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_agent_activity_rows"
+    },
+    {
+      "type": "varchar(64)",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "created_at",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_agent_activity_rows"
+    },
+    {
+      "type": "varchar(36)",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "id",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_delivery_attempts"
+    },
+    {
+      "type": "varchar(64)",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "created_at",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_delivery_attempts"
+    },
+    {
+      "type": "varchar(255)",
+      "typeSchema": null,
+      "notNull": false,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "user_id",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_delivery_attempts"
+    },
+    {
+      "type": "varchar(191)",
+      "typeSchema": null,
+      "notNull": false,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "environment_id",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_delivery_attempts"
+    },
+    {
+      "type": "varchar(191)",
+      "typeSchema": null,
+      "notNull": false,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "thread_id",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_delivery_attempts"
+    },
+    {
+      "type": "varchar(255)",
+      "typeSchema": null,
+      "notNull": false,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "device_id",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_delivery_attempts"
+    },
+    {
+      "type": "varchar(64)",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "kind",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_delivery_attempts"
+    },
+    {
+      "type": "varchar(64)",
+      "typeSchema": null,
+      "notNull": false,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "source_job_id",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_delivery_attempts"
+    },
+    {
+      "type": "varchar(16)",
+      "typeSchema": null,
+      "notNull": false,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "token_suffix",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_delivery_attempts"
+    },
+    {
+      "type": "integer",
+      "typeSchema": null,
+      "notNull": false,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "apns_status",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_delivery_attempts"
+    },
+    {
+      "type": "text",
+      "typeSchema": null,
+      "notNull": false,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "apns_reason",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_delivery_attempts"
+    },
+    {
+      "type": "varchar(128)",
+      "typeSchema": null,
+      "notNull": false,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "apns_id",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_delivery_attempts"
+    },
+    {
+      "type": "text",
+      "typeSchema": null,
+      "notNull": false,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "transport_error",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_delivery_attempts"
+    },
+    {
+      "type": "varchar(128)",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "thumbprint",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_dpop_proofs"
+    },
+    {
+      "type": "varchar(255)",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "jti",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_dpop_proofs"
+    },
+    {
+      "type": "integer",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "iat",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_dpop_proofs"
+    },
+    {
+      "type": "varchar(64)",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "expires_at",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_dpop_proofs"
+    },
+    {
+      "type": "varchar(64)",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "created_at",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_dpop_proofs"
+    },
+    {
+      "type": "varchar(64)",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "credential_id",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_environment_credentials"
+    },
+    {
+      "type": "varchar(191)",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "environment_id",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_environment_credentials"
+    },
+    {
+      "type": "text",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "environment_public_key",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_environment_credentials"
+    },
+    {
+      "type": "varchar(191)",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "credential_hash",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_environment_credentials"
+    },
+    {
+      "type": "varchar(64)",
+      "typeSchema": null,
+      "notNull": false,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "revoked_at",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_environment_credentials"
+    },
+    {
+      "type": "varchar(64)",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "created_at",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_environment_credentials"
+    },
+    {
+      "type": "varchar(64)",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "updated_at",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_environment_credentials"
+    },
+    {
+      "type": "varchar(191)",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "user_id",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_environment_links"
+    },
+    {
+      "type": "varchar(191)",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "environment_id",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_environment_links"
+    },
+    {
+      "type": "text",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": "'T3 Environment'",
+      "generated": null,
+      "identity": null,
+      "name": "environment_label",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_environment_links"
+    },
+    {
+      "type": "text",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "environment_public_key",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_environment_links"
+    },
+    {
+      "type": "text",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "endpoint_http_base_url",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_environment_links"
+    },
+    {
+      "type": "text",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "endpoint_ws_base_url",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_environment_links"
+    },
+    {
+      "type": "varchar(32)",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "endpoint_provider_kind",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_environment_links"
+    },
+    {
+      "type": "boolean",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": "true",
+      "generated": null,
+      "identity": null,
+      "name": "notifications_enabled",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_environment_links"
+    },
+    {
+      "type": "boolean",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": "true",
+      "generated": null,
+      "identity": null,
+      "name": "live_activities_enabled",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_environment_links"
+    },
+    {
+      "type": "boolean",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": "false",
+      "generated": null,
+      "identity": null,
+      "name": "managed_tunnels_enabled",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_environment_links"
+    },
+    {
+      "type": "varchar(191)",
+      "typeSchema": null,
+      "notNull": false,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "created_by_device_id",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_environment_links"
+    },
+    {
+      "type": "varchar(64)",
+      "typeSchema": null,
+      "notNull": false,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "revoked_at",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_environment_links"
+    },
+    {
+      "type": "varchar(64)",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "created_at",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_environment_links"
+    },
+    {
+      "type": "varchar(64)",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "updated_at",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_environment_links"
+    },
+    {
+      "type": "varchar(255)",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "user_id",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_live_activities"
+    },
+    {
+      "type": "varchar(255)",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "device_id",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_live_activities"
+    },
+    {
+      "type": "text",
+      "typeSchema": null,
+      "notNull": false,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "activity_push_token",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_live_activities"
+    },
+    {
+      "type": "varchar(64)",
+      "typeSchema": null,
+      "notNull": false,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "remote_start_queued_at",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_live_activities"
+    },
+    {
+      "type": "varchar(64)",
+      "typeSchema": null,
+      "notNull": false,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "remote_started_at",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_live_activities"
+    },
+    {
+      "type": "varchar(64)",
+      "typeSchema": null,
+      "notNull": false,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "ended_at",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_live_activities"
+    },
+    {
+      "type": "jsonb",
+      "typeSchema": null,
+      "notNull": false,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
... diff truncated: showing 800 of 1681 lines

You can send follow-ups to the cloud agent here.

Comment thread infra/relay/src/persistence/schema.ts
Comment thread apps/mobile/src/features/agent-awareness/deviceDiagnostics.ts Outdated
Comment thread infra/relay/src/agentActivity/Devices.ts Outdated
platform: varchar("platform", { length: 16 }).notNull().$type<"ios">(),
iosMajorVersion: integer("ios_major_version").notNull(),
appVersion: varchar("app_version", { length: 64 }),
bundleId: varchar("bundle_id", { length: 255 }),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High persistence/schema.ts:27

The new bundle_id and aps_environment columns are added to the relay_mobile_devices schema, but no SQL migration is included to create them. After deploy, the code in Devices.ts immediately inserts and selects these columns, so PostgreSQL rejects every device registration and list query with column "bundle_id" does not exist (and aps_environment likewise). Add the corresponding ALTER TABLE relay_mobile_devices ADD COLUMN ... migration under infra/relay/migrations/ so the columns exist before the new code runs.

Also found in 1 other location(s)

infra/relay/src/agentActivity/LiveActivities.ts:201

The new relayMobileDevices.bundleId / relayMobileDevices.apsEnvironment columns are referenced by listTargets (bundle_id, aps_environment) and other relay code, but this PR does not add a matching SQL migration for relay_mobile_devices. After deploying the code to an existing database, any listTargets query will start failing with column relay_mobile_devices.bundle_id does not exist (and likewise for aps_environment), breaking live-activity target lookup until the schema is manually patched.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @infra/relay/src/persistence/schema.ts around line 27:

The new `bundle_id` and `aps_environment` columns are added to the `relay_mobile_devices` schema, but no SQL migration is included to create them. After deploy, the code in `Devices.ts` immediately inserts and selects these columns, so PostgreSQL rejects every device registration and list query with `column "bundle_id" does not exist` (and `aps_environment` likewise). Add the corresponding `ALTER TABLE relay_mobile_devices ADD COLUMN ...` migration under `infra/relay/migrations/` so the columns exist before the new code runs.

Also found in 1 other location(s):
- infra/relay/src/agentActivity/LiveActivities.ts:201 -- The new `relayMobileDevices.bundleId` / `relayMobileDevices.apsEnvironment` columns are referenced by `listTargets` (`bundle_id`, `aps_environment`) and other relay code, but this PR does not add a matching SQL migration for `relay_mobile_devices`. After deploying the code to an existing database, any `listTargets` query will start failing with `column relay_mobile_devices.bundle_id does not exist` (and likewise for `aps_environment`), breaking live-activity target lookup until the schema is manually patched.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium

return yield* liveActivities.listTargets({ userId: input.target.user_id }).pipe(

The stale-job check in processSignedJob only compares the token via isCurrentSignedJobToken, so a queued job created before the device registered bundleId/apsEnvironment (or before those values changed) still passes the staleness guard when the token is unchanged. The job is then sent with the stale or missing routing metadata from the signed payload instead of the device's current values, causing APNs to reject the delivery with BadDeviceToken/DeviceTokenNotForTopic even though the registration has already been corrected. Consider comparing bundleId and apsEnvironment against the current target in the staleness check so jobs with outdated routing are treated as stale.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @infra/relay/src/agentActivity/ApnsDeliveries.ts around line 506:

The stale-job check in `processSignedJob` only compares the token via `isCurrentSignedJobToken`, so a queued job created before the device registered `bundleId`/`apsEnvironment` (or before those values changed) still passes the staleness guard when the token is unchanged. The job is then sent with the stale or missing routing metadata from the signed payload instead of the device's current values, causing APNs to reject the delivery with `BadDeviceToken`/`DeviceTokenNotForTopic` even though the registration has already been corrected. Consider comparing `bundleId` and `apsEnvironment` against the current target in the staleness check so jobs with outdated routing are treated as stale.

@macroscopeapp

macroscopeapp Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

20 blocking correctness issues found. Diff is too large for automated approval analysis. A human reviewer should evaluate this PR.

You can customize Macroscope's approvability policy. Learn more.

@juliusmarminge juliusmarminge force-pushed the t3code/live-activities-improvements branch from f81c2ca to 1d6309e Compare July 6, 2026 05:57
Comment thread apps/mobile/src/features/settings/SettingsRouteScreen.tsx Outdated
const deepLinkRow = attentionRow ?? row0;
const deepLink =
deepLinkRow && deepLinkRow.deepLink.startsWith("/") && !deepLinkRow.deepLink.startsWith("//")
? `t3code://${deepLinkRow.deepLink.slice(1)}`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium widgets/AgentActivity.tsx:81

widgetURL is hardcoded to the t3code:// scheme, but the app registers t3code-dev for development builds and t3code-preview for preview builds. On dev/preview builds the widget banner's tap URL uses a scheme the containing app does not register, so iOS will not open the app when the banner is tapped. Consider using a scheme that is registered in all build variants (or injecting the active scheme per-build).

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/widgets/AgentActivity.tsx around line 81:

`widgetURL` is hardcoded to the `t3code://` scheme, but the app registers `t3code-dev` for development builds and `t3code-preview` for preview builds. On dev/preview builds the widget banner's tap URL uses a scheme the containing app does not register, so iOS will not open the app when the banner is tapped. Consider using a scheme that is registered in all build variants (or injecting the active scheme per-build).

// while a user ignores an approval prompt, so they get a longer window. The
// underlying database row is left in place: a late publish for the thread
// refreshes updatedAt and the row becomes visible again.
const RUNNING_AGENT_ACTIVITY_ROW_TTL_MS = 2 * 60 * 60 * 1_000;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium agentActivity/AgentActivityPublisher.ts:203

RUNNING_AGENT_ACTIVITY_ROW_TTL_MS expires running/starting states after 2 hours based on updatedAt, but relay publishes are event-driven — a thread that stays in the same running phase for over 2 hours without a new event keeps its old updatedAt, so isExpiredAgentActivityState returns true and makeAggregateState filters it out. The aggregate then reports activeCount: 0 (or null/terminal) even though the job is still running, hiding active work from clients. Consider raising the running TTL to match the waiting TTL, or filtering on a heartbeat/last-seen timestamp that is refreshed independently of phase-change events.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @infra/relay/src/agentActivity/AgentActivityPublisher.ts around line 203:

`RUNNING_AGENT_ACTIVITY_ROW_TTL_MS` expires `running`/`starting` states after 2 hours based on `updatedAt`, but relay publishes are event-driven — a thread that stays in the same `running` phase for over 2 hours without a new event keeps its old `updatedAt`, so `isExpiredAgentActivityState` returns `true` and `makeAggregateState` filters it out. The aggregate then reports `activeCount: 0` (or `null`/terminal) even though the job is still running, hiding active work from clients. Consider raising the running TTL to match the waiting TTL, or filtering on a heartbeat/last-seen timestamp that is refreshed independently of phase-change events.

Comment thread apps/mobile/src/widgets/AgentActivity.tsx
Comment thread infra/relay/src/agentActivity/Devices.ts Outdated
Comment thread infra/relay/src/agentActivity/ApnsDeliveries.ts
Comment thread apps/mobile/src/features/agent-awareness/registrationPayload.ts
@juliusmarminge juliusmarminge force-pushed the t3code/live-activities-improvements branch from 1d6309e to 39d9dca Compare July 6, 2026 06:07
@github-actions github-actions Bot added size:L 100-499 changed lines (additions + deletions). and removed size:XL 500-999 changed lines (additions + deletions). labels Jul 6, 2026
Comment thread apps/mobile/src/features/agent-awareness/remoteRegistration.ts
@cursor

This comment has been minimized.

@juliusmarminge juliusmarminge force-pushed the t3code/live-activities-improvements branch from 39d9dca to 5225bdb Compare July 6, 2026 07:37
@github-actions github-actions Bot added size:XXL 1,000+ changed lines (additions + deletions). and removed size:L 100-499 changed lines (additions + deletions). labels Jul 6, 2026

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

There are 3 total unresolved issues (including 1 from previous review).

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Registration status stuck pending
    • The device-registration queue completion handler now resets a status still left as 'pending' back to 'unknown' when the effect exits without reaching the relay (signed out, missing relay config, cancelled generation, or unsupported platform).
  • ✅ Fixed: Sign-out cleanup on provider clear
    • Added a non-destructive suspend path (suspendAgentAwarenessRelayTokenProvider / suspendCloudRelayAccount) used by CloudAuthBridge unmount and missing-config teardown so Live Activities and the persisted registration record survive while the user remains signed in, keeping destructive cleanup only for real sign-out and account switches.

Create PR

Or push these changes by commenting:

@cursor push fa19061765
Preview (fa19061765)
diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts
--- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts
+++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts
@@ -143,6 +143,30 @@
   return identity === undefined || identity !== previousIdentity;
 }
 
+function removeRelaySubscriptions(): void {
+  pushToStartSubscription?.remove();
+  pushToStartSubscription = null;
+  pushTokenSubscription?.remove();
+  pushTokenSubscription = null;
+  appStateSubscription?.remove();
+  appStateSubscription = null;
+  if (activeLiveActivityRegistrationRetry) {
+    clearTimeout(activeLiveActivityRegistrationRetry);
+    activeLiveActivityRegistrationRetry = null;
+  }
+}
+
+// Drops the relay token provider without sign-out semantics: the Clerk session
+// can still be valid while the auth bridge tears down (remounts, provider tree
+// changes, missing cloud config). Listeners stop, but local Live Activities,
+// the persisted registration record, and the provider identity stay intact so
+// re-activating the same account does not end activities or force
+// re-registration.
+export function suspendAgentAwarenessRelayTokenProvider(): void {
+  relayTokenProvider = null;
+  removeRelaySubscriptions();
+}
+
 export function setAgentAwarenessRelayTokenProvider(
   provider: (() => Promise<string | null>) | null,
   identity?: string,
@@ -158,16 +182,7 @@
   relayTokenProvider = provider;
   relayTokenProviderIdentity = provider ? (identity ?? null) : null;
   if (!provider) {
-    pushToStartSubscription?.remove();
-    pushToStartSubscription = null;
-    pushTokenSubscription?.remove();
-    pushTokenSubscription = null;
-    appStateSubscription?.remove();
-    appStateSubscription = null;
-    if (activeLiveActivityRegistrationRetry) {
-      clearTimeout(activeLiveActivityRegistrationRetry);
-      activeLiveActivityRegistrationRetry = null;
-    }
+    removeRelaySubscriptions();
     // Without a signed-in user the relay can no longer update or end these
     // activities, so they would sit orphaned on the lock screen.
     endLocalLiveActivities("live activity cleanup after cloud sign-out failed");
@@ -472,6 +487,11 @@
     if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
       setRegistrationStatus("failed");
       logRegistrationError(next.context, squashAtomCommandFailure(result));
+    } else if (registrationStatus === "pending") {
+      // The registration exited without reaching the relay (signed out,
+      // missing relay config, or superseded by a newer generation); don't
+      // leave the status stuck on pending.
+      setRegistrationStatus("unknown");
     }
     logRegistrationDebug("device registration finished", { generation });
     if (activeDeviceRegistration === registration) {

diff --git a/apps/mobile/src/features/cloud/CloudAuthProvider.tsx b/apps/mobile/src/features/cloud/CloudAuthProvider.tsx
--- a/apps/mobile/src/features/cloud/CloudAuthProvider.tsx
+++ b/apps/mobile/src/features/cloud/CloudAuthProvider.tsx
@@ -15,6 +15,7 @@
 import { useAtomCommand } from "../../state/use-atom-command";
 import {
   setAgentAwarenessRelayTokenProvider,
+  suspendAgentAwarenessRelayTokenProvider,
   unregisterAgentAwarenessDeviceForCurrentUser,
 } from "../agent-awareness/remoteRegistration";
 import { resolveCloudPublicConfig, resolveRelayClerkTokenOptions } from "./publicConfig";
@@ -32,6 +33,14 @@
   setManagedRelaySession(appAtomRegistry, null);
 }
 
+// Teardown without sign-out semantics: the Clerk session may still be valid
+// (bridge remounts, provider tree changes, missing cloud config), so local
+// Live Activities and the persisted registration record must survive.
+export function suspendCloudRelayAccount(): void {
+  suspendAgentAwarenessRelayTokenProvider();
+  setManagedRelaySession(appAtomRegistry, null);
+}
+
 export function activateCloudRelayAccount(
   accountId: string,
   tokenProvider: () => Promise<string | null>,
@@ -143,7 +152,7 @@
   useEffect(
     () => () => {
       previousTokenProviderRef.current = null;
-      deactivateCloudRelayAccount();
+      suspendCloudRelayAccount();
     },
     [],
   );
@@ -158,7 +167,7 @@
 
   useEffect(() => {
     if (!publishableKey || !relayUrl) {
-      deactivateCloudRelayAccount();
+      suspendCloudRelayAccount();
     }
   }, [publishableKey, relayUrl]);

You can send follow-ups to the cloud agent here.

Comment thread apps/mobile/src/features/agent-awareness/remoteRegistration.ts
Comment thread apps/mobile/src/features/agent-awareness/remoteRegistration.ts
Comment thread apps/mobile/src/features/agent-awareness/remoteRegistration.ts
// Only reads as on when this device is actually registered with the
// relay; otherwise notifications cannot be delivered regardless of
// the local iOS permission.
value={notificationStatus === "enabled" && deviceRegistered}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium settings/SettingsRouteScreen.tsx:438

The deviceRegistered gate on the Device Notifications switch renders it false when relay registration fails, even though notificationStatus is "enabled". Because the switch shows off, the user cannot toggle it to reach handleDeviceNotificationsChange(false), which is the only path that opens iOS Settings to disable notifications. In the "permission granted, relay registration failed" case the switch misreports notifications as off and removes the in-app affordance for managing them. Consider gating only the Live Activity switch on deviceRegistered, or keeping the Device Notifications switch reflecting the actual iOS permission state so the user can still navigate to system Settings.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/features/settings/SettingsRouteScreen.tsx around line 438:

The `deviceRegistered` gate on the `Device Notifications` switch renders it `false` when relay registration fails, even though `notificationStatus` is `"enabled"`. Because the switch shows off, the user cannot toggle it to reach `handleDeviceNotificationsChange(false)`, which is the only path that opens iOS Settings to disable notifications. In the "permission granted, relay registration failed" case the switch misreports notifications as off and removes the in-app affordance for managing them. Consider gating only the Live Activity switch on `deviceRegistered`, or keeping the `Device Notifications` switch reflecting the actual iOS permission state so the user can still navigate to system Settings.

userId: input.userId,
deviceId: input.deviceId,
token: input.token,
...(input.bundleId ? { bundleId: input.bundleId } : {}),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High agentActivity/apnsDeliveryJobs.ts:247

makeApnsDeliveryJobPayload includes target.bundleId and target.apsEnvironment in the payload, and signatureForPayload signs that full object. Older relay workers that decode with a previous ApnsDeliveryJobPayload schema strip the new keys on decode (default effect Schema.Struct behavior) and recompute the HMAC over the reduced payload, so verifySignedApnsDeliveryJob returns ApnsDeliveryJobSignatureInvalid for every newly queued job carrying per-device routing. This breaks APNs delivery during rolling deploys when new producers share a queue with old consumers. Consider gating inclusion of these fields behind a flag, or otherwise ensuring the signed payload shape remains decodable by older schema versions, so signatures stay stable across the rollout.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @infra/relay/src/agentActivity/apnsDeliveryJobs.ts around line 247:

`makeApnsDeliveryJobPayload` includes `target.bundleId` and `target.apsEnvironment` in the payload, and `signatureForPayload` signs that full object. Older relay workers that decode with a previous `ApnsDeliveryJobPayload` schema strip the new keys on decode (default `effect` `Schema.Struct` behavior) and recompute the HMAC over the reduced payload, so `verifySignedApnsDeliveryJob` returns `ApnsDeliveryJobSignatureInvalid` for every newly queued job carrying per-device routing. This breaks APNs delivery during rolling deploys when new producers share a queue with old consumers. Consider gating inclusion of these fields behind a flag, or otherwise ensuring the signed payload shape remains decodable by older schema versions, so signatures stay stable across the rollout.

@juliusmarminge juliusmarminge force-pushed the t3code/live-activities-improvements branch from 5225bdb to 5086c14 Compare July 6, 2026 16:13
Comment thread apps/mobile/app.config.ts

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium

infoPlist: {
NSAppTransportSecurity: {

The frequentUpdates: true option under the expo-widgets plugin does not add the required NSSupportsLiveActivitiesFrequentUpdates key to Info.plist, so iOS keeps the standard Live Activity update budget and frequent updates are never enabled. Set NSSupportsLiveActivitiesFrequentUpdates: true in the ios.infoPlist block to actually grant the entitlement.

    infoPlist: {
+     NSSupportsLiveActivitiesFrequentUpdates: true,
      NSAppTransportSecurity: {
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/app.config.ts around lines 96-97:

The `frequentUpdates: true` option under the `expo-widgets` plugin does not add the required `NSSupportsLiveActivitiesFrequentUpdates` key to `Info.plist`, so iOS keeps the standard Live Activity update budget and frequent updates are never enabled. Set `NSSupportsLiveActivitiesFrequentUpdates: true` in the `ios.infoPlist` block to actually grant the entitlement.

return existing?.trim() ? existing : null;
}

export interface AgentAwarenessRegistrationRecord {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium lib/storage.ts:228

AgentAwarenessRegistrationRecord stores only identity and signature, so when the relay base URL changes but the user identity and device payload stay the same, loadAgentAwarenessRegistrationRecord returns the stale record and the app skips registration against the new relay. The new relay never receives this device's registration until sign-out clears the record. Consider including the relay base URL (or a hash of it) in the persisted record so a URL change invalidates the cached registration.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/lib/storage.ts around line 228:

`AgentAwarenessRegistrationRecord` stores only `identity` and `signature`, so when the relay base URL changes but the user identity and device payload stay the same, `loadAgentAwarenessRegistrationRecord` returns the stale record and the app skips registration against the new relay. The new relay never receives this device's registration until sign-out clears the record. Consider including the relay base URL (or a hash of it) in the persisted record so a URL change invalidates the cached registration.

Comment thread apps/mobile/src/features/agent-awareness/remoteRegistration.ts
value={notificationStatus === "enabled" && deviceRegistered}
onValueChange={handleDeviceNotificationsChange}
/>
<SettingsSwitchRow

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High settings/SettingsRouteScreen.tsx:441

When deviceRegistered is false but liveActivitiesEnabled is true in storage, the Live Activity Updates switch renders off even though liveActivityStatus is "enabled". Because onValueChange receives the next visual state (true), tapping the switch calls linkEnvironments() (the enable path) instead of persisting enabled: false, so the user cannot disable Live Activity updates from Settings until registration succeeds. Consider gating the disabled state or the alert on deviceRegistered rather than the switch value, so the toggle still reflects the saved preference and onValueChange fires with the correct next state.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/features/settings/SettingsRouteScreen.tsx around line 441:

When `deviceRegistered` is `false` but `liveActivitiesEnabled` is `true` in storage, the `Live Activity Updates` switch renders off even though `liveActivityStatus` is `"enabled"`. Because `onValueChange` receives the next visual state (`true`), tapping the switch calls `linkEnvironments()` (the enable path) instead of persisting `enabled: false`, so the user cannot disable Live Activity updates from Settings until registration succeeds. Consider gating the disabled state or the alert on `deviceRegistered` rather than the switch `value`, so the toggle still reflects the saved preference and `onValueChange` fires with the correct next state.

Comment thread infra/relay/src/agentActivity/ApnsDeliveries.ts

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.

There are 6 total unresolved issues (including 3 from previous reviews).

Autofix Details

Bugbot Autofix prepared fixes for 2 of the 3 issues found in the latest run.

  • ✅ Fixed: Foreground replay ends live activities
    • Replay now skips delivery when the aggregate is null but non-terminal rows still exist, so TTL-expired rows from a healthy long-running agent no longer trigger live_activity_end on foreground while truly orphaned activities (rows removed) are still ended.
  • ✅ Fixed: Same identity skips registration retry
    • setAgentAwarenessRelayTokenProvider now only skips re-enqueueing device registration for an unchanged identity when the registration status is "registered", so failed or stalled registrations retry when the auth effect re-runs.

Create PR

Or push these changes by commenting:

@cursor push 59517fdb7d
Preview (59517fdb7d)
diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts
--- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts
+++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts
@@ -186,7 +186,11 @@
     refreshActiveLiveActivityRemoteRegistration(),
     "active live activity registration after cloud sign-in failed",
   );
-  if (isExistingIdentity) {
+  // An unchanged identity only skips re-registration when the previous attempt
+  // actually reached the relay; a failed or stalled attempt must retry the next
+  // time the auth effect re-runs, or the device stays unregistered until an
+  // unrelated event (token rotation, environment link) happens to enqueue one.
+  if (isExistingIdentity && registrationStatus === "registered") {
     return;
   }
   enqueueDeviceRegistration({}, "device registration after cloud sign-in failed");

diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.ts
--- a/infra/relay/src/agentActivity/AgentActivityPublisher.ts
+++ b/infra/relay/src/agentActivity/AgentActivityPublisher.ts
@@ -119,6 +119,16 @@
         terminalState: null,
         nowMs: now.epochMilliseconds,
       });
+      // A null aggregate is ambiguous while non-terminal rows still exist:
+      // they may belong to a dead environment, or to a healthy long-running
+      // agent whose meaningful state (and therefore updatedAt) has not changed
+      // within the TTL. Sending in that case would end a possibly-healthy Live
+      // Activity on every foreground, so replay only delivers when live rows
+      // remain or the rows are truly gone (the thread ended and its end push
+      // may have been lost).
+      if (aggregate === null && activeStates.some((state) => !isTerminalPhase(state))) {
+        return null;
+      }
       return yield* apnsDeliveries.sendForTarget({
         target,
         aggregate,

You can send follow-ups to the cloud agent here.

Comment thread infra/relay/src/agentActivity/AgentActivityPublisher.ts
Comment thread apps/mobile/src/features/agent-awareness/remoteRegistration.ts
Comment thread apps/mobile/src/features/agent-awareness/remoteRegistration.ts
@juliusmarminge juliusmarminge force-pushed the t3code/live-activities-improvements branch from 5086c14 to a31735c Compare July 6, 2026 17:03
Comment thread apps/mobile/src/features/settings/SettingsRouteScreen.tsx
Comment thread apps/server/src/cli/connect.ts

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

There are 7 total unresolved issues (including 6 from previous reviews).

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Parallel registration marks failed incorrectly
    • Registration attempts now capture a success counter at start and only mark the status failed when no concurrent attempt succeeded while they were in flight, so a stale failure can no longer overwrite a relay-accepted registration.

Create PR

Or push these changes by commenting:

@cursor push d469c8569a
Preview (d469c8569a)
diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts
--- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts
+++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts
@@ -535,6 +535,48 @@
     }).pipe(Effect.provide(relayTestLayer));
   });
 
+  it.effect("keeps the registered status when a stale concurrent attempt fails", () => {
+    const fetchMock = vi.fn((request: RequestInfo | URL) => {
+      const url = request instanceof Request ? request.url : String(request);
+      return Promise.resolve(
+        Response.json(
+          url.endsWith("/v1/client/dpop-token")
+            ? {
+                access_token: "relay-dpop-token",
+                issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
+                token_type: "DPoP",
+                expires_in: 300,
+                scope: "mobile:registration",
+              }
+            : { ok: true },
+        ),
+      );
+    });
+    vi.stubGlobal("fetch", fetchMock);
+    Constants.expoConfig!.extra = {
+      relay: {
+        url: "https://relay.example.test/",
+      },
+    };
+
+    // Sign-in enqueues a registration attempt that stays parked in the
+    // background queue while the direct refresh below runs.
+    setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
+
+    return Effect.gen(function* () {
+      yield* refreshAgentAwarenessRegistration();
+      expect(getAgentAwarenessRegistrationStatus()).toBe("registered");
+
+      // The queued sign-in attempt now fails; its stale failure must not
+      // overwrite the registration the relay already accepted.
+      vi.mocked(loadOrCreateAgentAwarenessDeviceId).mockRejectedValueOnce(
+        new Error("device id unavailable"),
+      );
+      yield* runBackgroundOperations();
+      expect(getAgentAwarenessRegistrationStatus()).toBe("registered");
+    }).pipe(Effect.provide(relayTestLayer));
+  });
+
   it("clears registration status on cloud sign-out", () => {
     setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
     setAgentAwarenessRelayTokenProvider(null);

diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts
--- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts
+++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts
@@ -74,6 +74,13 @@
 let registrationStatus: AgentAwarenessRegistrationStatus = "unknown";
 const registrationStatusListeners = new Set<() => void>();
 
+// Registration runs both through the background queue and directly via
+// refreshAgentAwarenessRegistration, so attempts can overlap. Counting relay
+// acceptances lets a failing attempt detect that a concurrent attempt already
+// succeeded while it was in flight, so a stale failure cannot overwrite a
+// registration the relay accepted.
+let registrationSuccessCount = 0;
+
 function setRegistrationStatus(next: AgentAwarenessRegistrationStatus): void {
   if (registrationStatus === next) {
     return;
@@ -84,6 +91,18 @@
   }
 }
 
+function markRegistrationSucceeded(): void {
+  registrationSuccessCount++;
+  setRegistrationStatus("registered");
+}
+
+function markRegistrationFailed(successCountAtAttemptStart: number): void {
+  if (registrationSuccessCount !== successCountAtAttemptStart) {
+    return;
+  }
+  setRegistrationStatus("failed");
+}
+
 export function getAgentAwarenessRegistrationStatus(): AgentAwarenessRegistrationStatus {
   return registrationStatus;
 }
@@ -316,7 +335,7 @@
       catch: (cause) => cause,
     }).pipe(Effect.orElseSucceed(() => null));
     if (persisted && persisted.identity === identity && persisted.signature === signature) {
-      setRegistrationStatus("registered");
+      markRegistrationSucceeded();
       logRegistrationDebug("relay device registration skipped; already registered for account", {
         expectedGeneration,
       });
@@ -331,7 +350,7 @@
       clerkToken: token,
       payload: body,
     });
-    setRegistrationStatus("registered");
+    markRegistrationSucceeded();
     yield* Effect.promise(() =>
       saveAgentAwarenessRegistrationRecord({ identity, signature }).catch((error: unknown) => {
         logRegistrationError("persist registration record failed", error);
@@ -466,11 +485,12 @@
   };
   activeDeviceRegistration = registration;
   registration.operation = (async () => {
+    const successCountAtStart = registrationSuccessCount;
     const result = await settleAsyncResult(() =>
       runtime.runPromiseExit(registerDevice(next.input, generation)),
     );
     if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
-      setRegistrationStatus("failed");
+      markRegistrationFailed(successCountAtStart);
       logRegistrationError(next.context, squashAtomCommandFailure(result));
     }
     logRegistrationDebug("device registration finished", { generation });
@@ -675,14 +695,17 @@
   never,
   ManagedRelay.ManagedRelayClient
 > {
-  return registerDeviceForCurrentUser().pipe(
-    Effect.catch((error) =>
-      Effect.sync(() => {
-        setRegistrationStatus("failed");
-        logRegistrationError("device registration refresh failed", error);
-      }),
-    ),
-  );
+  return Effect.suspend(() => {
+    const successCountAtStart = registrationSuccessCount;
+    return registerDeviceForCurrentUser().pipe(
+      Effect.catch((error) =>
+        Effect.sync(() => {
+          markRegistrationFailed(successCountAtStart);
+          logRegistrationError("device registration refresh failed", error);
+        }),
+      ),
+    );
+  });
 }
 
 export function __resetAgentAwarenessRemoteRegistrationForTest(): void {
@@ -703,6 +726,7 @@
   activeDeviceRegistration = null;
   pendingDeviceRegistration = null;
   registrationStatus = "unknown";
+  registrationSuccessCount = 0;
   registrationStatusListeners.clear();
 }

You can send follow-ups to the cloud agent here.

Comment thread apps/mobile/src/features/agent-awareness/remoteRegistration.ts Outdated
- Per-device APNs routing (bundle id + environment) so preview/dev builds
  receive pushes from the shared relay (+ migration for the new columns)
- Settings notification/Live Activity toggles reflect actual relay
  registration success; cannot read as enabled when the device is not registered
- Persistent register-once: skip re-registering an unchanged payload for the
  same account across launches; clear only on sign-out
- Headless publish + tunnel-free linking: `t3 connect publish` toggles agent
  activity publishing and auto-establishes a publish-only (no managed tunnel)
  link, and `t3 connect link --publish-only` links for publishing alone, so
  activity flows to mobile clients that reach the environment out of band
  (e.g. Tailscale) without T3 Connect. Relay accepts publish-only links with a
  nominal endpoint; env proofs advertise the manual provider kind
- Foreground Live Activity refresh, stale/ghost-row hardening, dedup fixes
- Tighten widget deep linking and per-row rendering for active agents

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@juliusmarminge juliusmarminge force-pushed the t3code/live-activities-improvements branch from a31735c to 9c3eb7b Compare July 6, 2026 17:24
Comment thread apps/mobile/src/features/agent-awareness/remoteRegistration.ts
// Development builds are Xcode-signed and receive sandbox APNs tokens;
// preview and production builds are distribution-signed and use production
// APNs. The relay routes each device's pushes accordingly.
export function resolveApsEnvironment(appVariant: unknown): "sandbox" | "production" {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium agent-awareness/registrationPayload.ts:8

resolveApsEnvironment returns "production" for every value except the literal string "development". Locally run ios:preview and ios:prod builds launched via expo run:ios are development-signed and receive sandbox APNs tokens, but this function classifies them as "production". The relay then routes pushes to the production APNs gateway for a sandbox token, so notifications and Live Activities fail to deliver for those builds. Consider keying off the signing/provisioning environment (e.g. EAS build profile or Configuration/ entitlements) rather than the variant name, or document why locally-run preview/prod builds are expected to use production APNs.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/features/agent-awareness/registrationPayload.ts around line 8:

`resolveApsEnvironment` returns `"production"` for every value except the literal string `"development"`. Locally run `ios:preview` and `ios:prod` builds launched via `expo run:ios` are development-signed and receive sandbox APNs tokens, but this function classifies them as `"production"`. The relay then routes pushes to the production APNs gateway for a sandbox token, so notifications and Live Activities fail to deliver for those builds. Consider keying off the signing/provisioning environment (e.g. EAS build profile or `Configuration`/ entitlements) rather than the variant name, or document why locally-run `preview`/`prod` builds are expected to use production APNs.

Comment thread apps/server/src/cli/connect.ts
Comment thread apps/mobile/src/features/agent-awareness/remoteRegistration.ts
Comment thread packages/contracts/src/environmentHttp.ts Outdated
Comment on lines 1745 to 1750
primaryCloudLinkState.refresh();
const refreshResult = await refreshRelayEnvironments();
if (refreshResult._tag === "Failure") {
if (!isAtomCommandInterrupted(refreshResult)) {
reportUpdateFailure(squashAtomCommandFailure(refreshResult));
}
setIsUpdating(false);
return;
if (refreshResult._tag === "Failure" && !isAtomCommandInterrupted(refreshResult)) {
reportUpdateFailure(squashAtomCommandFailure(refreshResult));
return false;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium settings/ConnectionsSettings.tsx:1745

When reconcileCloudState completes a link/unlink and preference update successfully, the mutation is already committed and primaryCloudLinkState.refresh() has been called. If the follow-up refreshRelayEnvironments() call at line 1747 then fails, the function returns false, shows an error toast, and suppresses the success toast — so the user is told their T3 Connect change failed even though it was actually applied. Consider returning true before the relay refresh (or not reporting its failure as a mutation failure), so transient relay-discovery errors don't mask a successful mutation.

    primaryCloudLinkState.refresh();
+    return true;
     const refreshResult = await refreshRelayEnvironments();
     if (refreshResult._tag === "Failure" && !isAtomCommandInterrupted(refreshResult)) {
       reportUpdateFailure(squashAtomCommandFailure(refreshResult));
       return false;
     }
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/components/settings/ConnectionsSettings.tsx around lines 1745-1750:

When `reconcileCloudState` completes a link/unlink and preference update successfully, the mutation is already committed and `primaryCloudLinkState.refresh()` has been called. If the follow-up `refreshRelayEnvironments()` call at line 1747 then fails, the function returns `false`, shows an error toast, and suppresses the success toast — so the user is told their T3 Connect change failed even though it was actually applied. Consider returning `true` before the relay refresh (or not reporting its failure as a mutation failure), so transient relay-discovery errors don't mask a successful mutation.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High cloud/http.ts:369

makeCloudLinkProof calls isAllowedEndpointOrigin for every link proof, including manual providerKind requests. That helper only permits loopback hosts, so a manual or publish-only link from a non-loopback origin (e.g. Tailscale or directly exposed host) is rejected with Invalid managed endpoint origin., blocking the out-of-band endpoint flow that isSupportedLinkProviderKind was added to support. Consider skipping the loopback origin check when providerKind === "manual".

  if (
    !isSupportedLinkProviderKind(request) ||
    (request.endpoint.providerKind === "cloudflare_tunnel" &&
      !isAllowedEndpointOrigin({
        origin: request.origin,
        requestUrl,
      }))
  ) {
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/cloud/http.ts around lines 369-375:

`makeCloudLinkProof` calls `isAllowedEndpointOrigin` for every link proof, including manual `providerKind` requests. That helper only permits loopback hosts, so a manual or publish-only link from a non-loopback origin (e.g. Tailscale or directly exposed host) is rejected with `Invalid managed endpoint origin.`, blocking the out-of-band endpoint flow that `isSupportedLinkProviderKind` was added to support. Consider skipping the loopback origin check when `providerKind === "manual"`.

…re-registration spam

- Rework the AgentActivity layout: attention-first row ordering, single-line
  rows (title + inline project + status) shared by the banner and expanded
  Dynamic Island, centered dot-separated banner headline, up to 5 banner rows,
  a dedicated watch/CarPlay bannerSmall, and a phase-glyph minimal view for
  the shared island
- Match status tints to the web sidebar pills (Sidebar.logic.ts), switching
  between the light (-600) and dark (-300) palette off the activity color
  scheme: iPhone renders on dark material, but macOS mirroring/notification
  center renders on light where the dark palette was illegible
- Align the relay's "Starting" status label to the web sidebar's "Connecting"
- Ship the branded T3 mark to the widget extension: expo-widgets generates the
  target with no Resources phase and hand-added ones are never scheduled, so
  withWidgetLogoAsset.cjs installs the SVG template image set and an
  alwaysOutOfDate actool shell phase (scripts/wire-widget-asset-catalog.cjs
  applies the same wiring to an already-generated ios/ without a prebuild)
- Register a Live Activity push token with the relay only once per accepted
  token: the refresh runs on sign-in, app foreground, and every environment
  connection update, and was re-POSTing identical {deviceId, token} payloads
  in a loop; the register-once set clears on sign-out/identity change

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
// Any registered scheme variant routes back to this app; taps are delivered
// to the widget's containing app, so the prod scheme is safe for all builds.
const deepLinkRow = attentionRow ?? row0;
const deepLink =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium widgets/AgentActivity.tsx:140

The deep-link guard at deepLinkRow.deepLink.startsWith("/") && !deepLinkRow.deepLink.startsWith("//") accepts any path, so values like /settings or /threads/env/thread?x=1 are turned into t3code://settings or t3code://threads/env/thread?x=1. Unlike extractAgentNotificationDeepLink, this allows non-thread paths and query/hash-bearing links to route Live Activity taps to the wrong screen. Consider matching the app's existing normalization so only valid thread links produce a deepLink.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/widgets/AgentActivity.tsx around line 140:

The deep-link guard at `deepLinkRow.deepLink.startsWith("/") && !deepLinkRow.deepLink.startsWith("//")` accepts any path, so values like `/settings` or `/threads/env/thread?x=1` are turned into `t3code://settings` or `t3code://threads/env/thread?x=1`. Unlike `extractAgentNotificationDeepLink`, this allows non-thread paths and query/hash-bearing links to route Live Activity taps to the wrong screen. Consider matching the app's existing normalization so only valid thread links produce a `deepLink`.

Two live repros from phone-created threads:

- Creating a thread and immediately backgrounding produced no card: the
  arm call ran after `await createProjectThread`, and by then the app had
  left the foreground, where ActivityKit rejects starts. Arm at submit
  time instead — the seed content is already known — and a failed
  creation self-corrects because the token registration's replay finds no
  work and ends the card.
- Two cards at once: the app-open reconcile checks for existing
  activities, then yields for the relay snapshot request; an arm-on-send
  landing inside that window made both paths see "no card" and both
  start one. The reconcile now re-checks instances after the snapshot
  await and adopts the meanwhile-armed card, and any duplicate that does
  slip through gets ended (keep-first) on the next reconcile pass, which
  also cleans up cards stranded by earlier builds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
}).pipe(Effect.orElseSucceed(() => [] as ReadonlyArray<LiveActivity<AgentActivityProps>>));
if (armedMeanwhile.length > 0) {
activities = [...armedMeanwhile];
} else if (snapshot?.aggregate && snapshot.aggregate.activeCount > 0) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium agent-awareness/remoteRegistration.ts:1020

After sign-out, refreshActiveLiveActivityRemoteRegistration() can still start a Live Activity, leaving an orphaned card on the lock screen that no signed-in user can update or end. The guard at the top only checks relayTokenProvider once before readAgentActivitySnapshot() yields; when sign-out runs meanwhile, setAgentAwarenessRelayTokenProvider(null) calls endLocalLiveActivities(), but the generator resumes and calls AgentActivity.start(...) anyway, creating a fresh card after cleanup. Consider re-checking relayTokenProvider (or deviceRegistrationGeneration) after the snapshot await and before AgentActivity.start.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/features/agent-awareness/remoteRegistration.ts around line 1020:

After sign-out, `refreshActiveLiveActivityRemoteRegistration()` can still start a Live Activity, leaving an orphaned card on the lock screen that no signed-in user can update or end. The guard at the top only checks `relayTokenProvider` once before `readAgentActivitySnapshot()` yields; when sign-out runs meanwhile, `setAgentAwarenessRelayTokenProvider(null)` calls `endLocalLiveActivities()`, but the generator resumes and calls `AgentActivity.start(...)` anyway, creating a fresh card after cleanup. Consider re-checking `relayTokenProvider` (or `deviceRegistrationGeneration`) after the snapshot await and before `AgentActivity.start`.

Comment thread infra/relay/src/agentActivity/ApnsDeliveries.ts
Comment thread infra/relay/src/agentActivity/AgentActivityPublisher.ts
Comment thread apps/mobile/src/features/agent-awareness/remoteRegistration.ts
Comment thread apps/mobile/src/features/agent-awareness/remoteRegistration.ts
Comment thread apps/mobile/src/features/agent-awareness/remoteRegistration.ts

@macroscopeapp macroscopeapp Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Effect Service Conventions: one finding in the new ApnsProviderTokens service module. See inline comment.

Posted via Macroscope — Effect Service Conventions

Comment thread infra/relay/src/agentActivity/ApnsProviderTokens.ts Outdated
Live repro: answering an agent's question left the card frozen on
"Input". The publish stream showed why — the thread's resolved awareness
state flapped to null during the answer handoff (running, deleted,
running, deleted), and each null publish removed the thread row, emptied
the aggregate, and ended the armed card mid-conversation. Under
push-to-start the next running publish would have resurrected a card;
with app-armed activities a spurious tombstone is fatal until the next
app open.

- The env server no longer tombstones a thread that was live at its last
  publish: the null resolution is deferred five seconds and re-resolved,
  publishing only if it still holds. Genuine deletions propagate a few
  seconds later; transient handoff gaps and shell lookup races never
  fire.
- A relay end with no final content-state used to leave the card's last
  frame (that stale "Input") on the lock screen for the full five-minute
  dismissal window; contentless ends now dismiss in seconds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Deferred tombstone bypasses publish queue
    • Deferred tombstone confirmations now re-enter the shared publish worker after the delay (serializing them against event-driven publishes) and a pending-confirmation set dedupes deferred timers per thread.

Create PR

Or push these changes by commenting:

@cursor push 93976e2ee4
Preview (93976e2ee4)
diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts
--- a/apps/server/src/relay/AgentAwarenessRelay.ts
+++ b/apps/server/src/relay/AgentAwarenessRelay.ts
@@ -272,6 +272,7 @@
   const cloudLinkKeyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(secrets);
   const activeSnapshotPublishedRef = yield* Ref.make(false);
   const publishedStateByThreadRef = yield* Ref.make(new Map<ThreadId, string>());
+  const pendingTombstoneConfirmsRef = yield* Ref.make(new Set<ThreadId>());
 
   const readSecretString = (name: string) =>
     secrets
@@ -306,7 +307,7 @@
       transformClient: relayEnvironmentClient(relayConfig.environmentCredential),
     }).pipe(Effect.provide(FetchHttpClient.layer));
 
-  // Assigned after publishThreadUnsafe below; indirection keeps the deferred
+  // Assigned after the publish worker below; indirection keeps the deferred
   // tombstone confirmation from making the definition self-referential.
   let confirmTombstoneLater: (threadId: ThreadId) => Effect.Effect<void> = () => Effect.void;
 
@@ -408,6 +409,20 @@
       // immediately deletes the thread from the lock-screen card mid-
       // conversation. Defer, re-resolve, and only tombstone if it holds;
       // genuinely deleted threads still propagate a few seconds later.
+      const alreadyPending = yield* Ref.modify(pendingTombstoneConfirmsRef, (pending) => {
+        if (pending.has(threadId)) {
+          return [true, pending] as const;
+        }
+        return [false, new Set(pending).add(threadId)] as const;
+      });
+      if (alreadyPending) {
+        yield* Effect.logDebug("agent activity tombstone confirmation already pending", {
+          environmentId,
+          threadId,
+          reason: snapshot.reason,
+        });
+        return;
+      }
       yield* Effect.logInfo("agent activity tombstone deferred pending confirmation", {
         environmentId,
         threadId,
@@ -442,23 +457,15 @@
     });
   });
 
-  confirmTombstoneLater = (threadId) =>
-    publishThreadUnsafe(threadId, { confirmTombstone: true }).pipe(
-      Effect.delay("5 seconds"),
-      Effect.catchCause((cause) =>
-        Effect.logWarning("deferred agent activity tombstone failed", {
-          threadId,
-          cause: Cause.pretty(cause),
-        }),
-      ),
-      Effect.asVoid,
-    );
-
-  const publishThread: AgentAwarenessRelay["Service"]["publishThread"] = (threadId) =>
-    publishThreadUnsafe(threadId).pipe(
+  const runPublishTask = (task: {
+    readonly threadId: ThreadId;
+    readonly confirmTombstone?: boolean;
+  }) =>
+    publishThreadUnsafe(task.threadId, { confirmTombstone: task.confirmTombstone === true }).pipe(
       Effect.catchCause((cause) => {
         return Effect.logWarning("agent activity publish failed", {
-          threadId,
+          threadId: task.threadId,
+          confirmTombstone: task.confirmTombstone === true,
           cause: Cause.pretty(cause),
         });
       }),
@@ -466,6 +473,9 @@
       withRelayClientTracing,
     );
 
+  const publishThread: AgentAwarenessRelay["Service"]["publishThread"] = (threadId) =>
+    runPublishTask({ threadId });
+
   const publishActiveThreadsUnsafe = Effect.gen(function* () {
     const publishAgentActivity = yield* readPublishAgentActivityEnabled.pipe(
       Effect.orElseSucceed(() => false),
@@ -515,8 +525,24 @@
       }
     });
 
-  const worker = yield* makeDrainableWorker(publishThread);
+  const worker = yield* makeDrainableWorker(runPublishTask);
 
+  // Deferred confirmations re-enter the same publish worker so they serialize
+  // against event-driven publishes instead of racing them from a detached
+  // fiber; only the delay itself runs outside the queue.
+  confirmTombstoneLater = (threadId) =>
+    Effect.sleep("5 seconds").pipe(
+      Effect.andThen(
+        Ref.update(pendingTombstoneConfirmsRef, (pending) => {
+          const next = new Set(pending);
+          next.delete(threadId);
+          return next;
+        }),
+      ),
+      Effect.andThen(worker.enqueue({ threadId, confirmTombstone: true })),
+      Effect.asVoid,
+    );
+
   const start: AgentAwarenessRelay["Service"]["start"] = Effect.fn("AgentAwarenessRelay.start")(
     function* () {
       const [relayConfig, publishEnabled] = yield* Effect.all([
@@ -567,7 +593,7 @@
           return Effect.logDebug("agent activity publishing queued thread publish", {
             eventType: event.type,
             threadId,
-          }).pipe(Effect.andThen(worker.enqueue(threadId)));
+          }).pipe(Effect.andThen(worker.enqueue({ threadId })));
         }),
       );
     },

You can send follow-ups to the cloud agent here.

Comment thread apps/server/src/relay/AgentAwarenessRelay.ts Outdated
Comment thread apps/server/src/relay/AgentAwarenessRelay.ts Outdated
@cursor

cursor Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Bugbot Autofix prepared fixes for all 5 issues found in the latest run.

  • ✅ Fixed: Replay ends newly armed card
    • Arm-on-send registrations now carry an armedForLocalWork flag and the relay skips the registration replay when the aggregate has no live agents, so the freshly armed card is never ended before the work's first publish arrives.
  • ✅ Fixed: Replay paints stale Done state
    • The same armedForLocalWork replay skip also covers terminal-only aggregates (activeCount 0), so a stale recent-Done snapshot can no longer overwrite the just-armed Connecting placeholder.
  • ✅ Fixed: Arming ignores Live Activity preference
    • armAgentAwarenessLiveActivityForLocalWork now loads saved preferences and no-ops when liveActivitiesEnabled is false (or preferences are unreadable) before starting a card or registering tokens.
  • ✅ Fixed: Concurrent sends spawn duplicate cards
    • A module-level in-flight guard reserved synchronously at arm entry makes the instance check and AgentActivity.start effectively atomic, so concurrent sends on different threads can no longer each start a card.
  • ✅ Fixed: Foreground prime skips terminal snapshot
    • Foreground priming now arms a card for any non-null snapshot aggregate instead of requiring activeCount > 0, so terminal-only Done/Failed snapshots are primed on app open.

Create PR

Or push these changes by commenting:

@cursor push 6fb7435bba
Preview (6fb7435bba)
diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts
--- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts
+++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts
@@ -108,6 +108,7 @@
   };
 }
 let activeLiveActivityRegistrationRetry: ReturnType<typeof setTimeout> | null = null;
+let armLiveActivityForLocalWorkInFlight = false;
 let relayTokenProvider: (() => Promise<string | null>) | null = null;
 let relayTokenProviderIdentity: string | null = null;
 let deviceRegistrationGeneration = 0;
@@ -438,8 +439,9 @@
 // Arms the lock-screen card the moment the user starts agent work from this
 // phone, while the app is still foregrounded and the fresh activity's token
 // can be registered immediately. The seeded row is a best-effort placeholder;
-// the relay's registration replay repaints it with the authoritative
-// aggregate within seconds. No-ops when a card is already armed.
+// the relay repaints it with the authoritative aggregate once the work's
+// first publish lands. No-ops when a card is already armed or the user
+// disabled Live Activity updates in settings.
 export function armAgentAwarenessLiveActivityForLocalWork(input: {
   readonly threadTitle: string;
   readonly projectTitle: string;
@@ -447,40 +449,61 @@
   if (!canRegisterRemoteLiveActivities() || !relayTokenProvider) {
     return;
   }
-  try {
-    if (AgentActivity.getInstances().length > 0) {
-      return;
+  // Reserve arming before the preference load yields: two quick sends on
+  // different threads would otherwise both observe zero instances and each
+  // start a card, while the relay tracks exactly one card per device.
+  if (armLiveActivityForLocalWorkInFlight) {
+    return;
+  }
+  armLiveActivityForLocalWorkInFlight = true;
+  void (async () => {
+    try {
+      // Honor the saved settings toggle before creating anything; without
+      // readable preferences the registration pipeline cannot run either, so
+      // an armed card would never receive updates.
+      const preferences = await loadPreferences().catch((error: unknown) => {
+        logRegistrationError("live activity arming preference load failed", error);
+        return null;
+      });
+      if (preferences === null || preferences.liveActivitiesEnabled === false) {
+        return;
+      }
+      if (AgentActivity.getInstances().length > 0) {
+        return;
+      }
+      const nowIso = new Date(Date.now()).toISOString();
+      const activity = AgentActivity.start({
+        title: "T3 Code",
+        subtitle: "Agent work in progress",
+        activeCount: 1,
+        updatedAt: nowIso,
+        activities: [
+          {
+            environmentId: "",
+            threadId: "",
+            projectTitle: input.projectTitle,
+            threadTitle: input.threadTitle,
+            modelTitle: "",
+            phase: "starting",
+            status: "Connecting",
+            updatedAt: nowIso,
+            deepLink: "/",
+          },
+        ],
+      });
+      logRegistrationDebug("live activity card armed for local work", {
+        threadTitle: input.threadTitle,
+      });
+      runRegistrationInBackground(
+        registerLiveActivityPushToken({ activity, armedForLocalWork: true }).pipe(Effect.asVoid),
+        "live activity arming after local task start failed",
+      );
+    } catch (error) {
+      logRegistrationError("live activity arming failed", error);
+    } finally {
+      armLiveActivityForLocalWorkInFlight = false;
     }
-    const nowIso = new Date(Date.now()).toISOString();
-    const activity = AgentActivity.start({
-      title: "T3 Code",
-      subtitle: "Agent work in progress",
-      activeCount: 1,
-      updatedAt: nowIso,
-      activities: [
-        {
-          environmentId: "",
-          threadId: "",
-          projectTitle: input.projectTitle,
-          threadTitle: input.threadTitle,
-          modelTitle: "",
-          phase: "starting",
-          status: "Connecting",
-          updatedAt: nowIso,
-          deepLink: "/",
-        },
-      ],
-    });
-    logRegistrationDebug("live activity card armed for local work", {
-      threadTitle: input.threadTitle,
-    });
-    runRegistrationInBackground(
-      registerLiveActivityPushToken({ activity }).pipe(Effect.asVoid),
-      "live activity arming after local task start failed",
-    );
-  } catch (error) {
-    logRegistrationError("live activity arming failed", error);
-  }
+  })();
 }
 
 function readAgentActivitySnapshot(): Effect.Effect<
@@ -821,6 +844,7 @@
   deviceRegistrationGeneration++;
   activeDeviceRegistration = null;
   pendingDeviceRegistration = null;
+  armLiveActivityForLocalWorkInFlight = false;
   registrationStatus = "unknown";
   registrationStatusListeners.clear();
   registeredActivityPushTokens.clear();
@@ -853,6 +877,7 @@
 
 export function registerLiveActivityPushToken(input: {
   readonly activity: LiveActivity<AgentActivityProps>;
+  readonly armedForLocalWork?: boolean;
 }): Effect.Effect<boolean, unknown, ManagedRelay.ManagedRelayClient> {
   return Effect.gen(function* () {
     if (!canRegisterRemoteLiveActivities()) {
@@ -893,6 +918,7 @@
           runRegistrationInBackground(
             registerLiveActivityPushTokenValue({
               activityPushToken: event.pushToken,
+              armedForLocalWork: input.armedForLocalWork === true,
             }),
             "live activity token listener registration failed",
           );
@@ -903,12 +929,14 @@
 
     return yield* registerLiveActivityPushTokenValue({
       activityPushToken,
+      armedForLocalWork: input.armedForLocalWork === true,
     });
   });
 }
 
 function registerLiveActivityPushTokenValue(input: {
   readonly activityPushToken: string;
+  readonly armedForLocalWork: boolean;
 }): Effect.Effect<boolean, unknown, ManagedRelay.ManagedRelayClient> {
   return Effect.gen(function* () {
     const acceptedAt = registeredActivityPushTokens.get(input.activityPushToken);
@@ -929,6 +957,7 @@
     const registered = yield* registerLiveActivityWithRelay({
       deviceId,
       activityPushToken: input.activityPushToken,
+      ...(input.armedForLocalWork ? { armedForLocalWork: true } : {}),
     });
     if (registered) {
       registeredActivityPushTokens.set(input.activityPushToken, Date.now());
@@ -1017,7 +1046,10 @@
         }).pipe(Effect.orElseSucceed(() => [] as ReadonlyArray<LiveActivity<AgentActivityProps>>));
         if (armedMeanwhile.length > 0) {
           activities = [...armedMeanwhile];
-        } else if (snapshot?.aggregate && snapshot.aggregate.activeCount > 0) {
+        } else if (snapshot?.aggregate) {
+          // Any non-null aggregate is worth a card: the relay only returns
+          // one when there is content to show — live agents, or recently
+          // finished work still inside its Done/Failed display window.
           const aggregate = snapshot.aggregate;
           const primed = yield* Effect.try({
             try: () =>

diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
--- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
+++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
@@ -528,8 +528,8 @@
     flow.setSubmitting(true);
     // Arm the lock-screen card before the async thread creation: backgrounding
     // the app right after tapping submit would otherwise reject the foreground
-    // -only Activity start. If creation fails, the token registration's replay
-    // finds no work and ends the card within seconds.
+    // -only Activity start. If creation fails, the next reconcile or publish
+    // with nothing to show ends the stranded card.
     armAgentAwarenessLiveActivityForLocalWork({
       threadTitle: deriveThreadTitleFromPrompt(initialMessageText),
       projectTitle: selectedProject.title,

diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.ts
--- a/infra/relay/src/agentActivity/AgentActivityPublisher.ts
+++ b/infra/relay/src/agentActivity/AgentActivityPublisher.ts
@@ -42,6 +42,7 @@
     readonly replayForLiveActivityRegistration: (input: {
       readonly userId: string;
       readonly deviceId: string;
+      readonly armedForLocalWork?: boolean;
     }) => Effect.Effect<RelayDeliveryResult | null, AgentActivityPublishError>;
   }
 >()("t3code-relay/agentActivity/AgentActivityPublisher") {}
@@ -125,6 +126,15 @@
         terminalState: null,
         nowMs: now.epochMilliseconds,
       });
+      // A registration for a card the app just armed for locally started
+      // work races that work's first publish: its rows usually do not exist
+      // yet, so an aggregate without live agents means "not published yet",
+      // not "nothing to show". Delivering here would end the fresh card (and
+      // drop its token) or overwrite the placeholder with stale Done rows —
+      // skip and let the imminent publish paint it instead.
+      if (input.armedForLocalWork === true && (aggregate === null || aggregate.activeCount === 0)) {
+        return null;
+      }
       return yield* apnsDeliveries.sendForTarget({
         target,
         aggregate,

diff --git a/infra/relay/src/agentActivity/MobileRegistrations.ts b/infra/relay/src/agentActivity/MobileRegistrations.ts
--- a/infra/relay/src/agentActivity/MobileRegistrations.ts
+++ b/infra/relay/src/agentActivity/MobileRegistrations.ts
@@ -78,6 +78,7 @@
           .replayForLiveActivityRegistration({
             userId: input.userId,
             deviceId: input.payload.deviceId,
+            ...(input.payload.armedForLocalWork ? { armedForLocalWork: true } : {}),
           })
           .pipe(
             Effect.tapError((error) =>

diff --git a/packages/contracts/src/relay.ts b/packages/contracts/src/relay.ts
--- a/packages/contracts/src/relay.ts
+++ b/packages/contracts/src/relay.ts
@@ -84,6 +84,11 @@
 export const RelayLiveActivityRegistrationRequest = Schema.Struct({
   deviceId: TrimmedNonEmptyString,
   activityPushToken: TrimmedNonEmptyString,
+  // Set when the app armed this activity for work it just started locally.
+  // The registration replay then races the first agent-activity publish, so
+  // an aggregate without live work means "not published yet", not "nothing
+  // to show" — the relay must not end or repaint the freshly armed card.
+  armedForLocalWork: Schema.optional(Schema.Boolean),
 });
 export type RelayLiveActivityRegistrationRequest = typeof RelayLiveActivityRegistrationRequest.Type;

You can send follow-ups to the cloud agent here.

Arming on send registers the card's token immediately, and the
registration replay can reach the relay before the environment's first
publish for the brand-new thread. With end-on-empty, that replay saw an
empty aggregate, ended the seconds-old card, and retired its token — so
the running/input updates that arrived moments later had no address and
the card sat orphaned at its seed "Connecting" content (observed live:
card armed and ended at 05:36:38Z, one second before the thread's
waiting_for_input row landed).

An empty aggregate within two minutes of arming now delivers nothing,
leaving the seed content in place for the state that is about to arrive;
past the grace window, empty still ends the card as designed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread infra/relay/src/agentActivity/ApnsDeliveries.ts

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Grace blocks legitimate activity end
    • LiveActivities.register no longer resets remote_started_at when the same activity token is re-registered (foreground reconciliation), so the freshly-armed grace window measures from the true arming time and orphaned cards receive their live_activity_end once it expires.

Create PR

Or push these changes by commenting:

@cursor push 5ab40bb88e
Preview (5ab40bb88e)
diff --git a/infra/relay/src/agentActivity/LiveActivities.test.ts b/infra/relay/src/agentActivity/LiveActivities.test.ts
--- a/infra/relay/src/agentActivity/LiveActivities.test.ts
+++ b/infra/relay/src/agentActivity/LiveActivities.test.ts
@@ -109,10 +109,12 @@
             remoteStartedAt: null,
           }),
         ]);
+        // The claim skips this device's own row: it must stay intact so the
+        // upsert can tell a same-token re-registration apart from a fresh arm.
         expect(updateConditions.map((condition) => dialect.sqlToQuery(condition))).toEqual([
           {
-            sql: '"relay_live_activities"."activity_push_token" = $1',
-            params: ["activity-push-token"],
+            sql: '(("relay_live_activities"."activity_push_token" = $1) and ((("relay_live_activities"."user_id" <> $2) or ("relay_live_activities"."device_id" <> $3))))',
+            params: ["activity-push-token", "user-2", "device-1"],
           },
         ]);
         expect(insertedValues).toEqual([
@@ -131,12 +133,20 @@
           expect.objectContaining({
             activityPushToken: "activity-push-token",
             remoteStartQueuedAt: null,
-            remoteStartedAt: expect.any(String),
             endedAt: null,
             lastAggregateJson: null,
             lastLiveActivityDeliveryAt: null,
           }),
         );
+        // Re-registering the SAME token (foreground reconciliation) must not
+        // restart the arming clock, or the end-on-empty grace window would
+        // renew forever and orphaned cards would never receive their end.
+        const remoteStartedAtSet = dialect.sqlToQuery(
+          conflictConfigs[0]?.set?.remoteStartedAt as SQL,
+        );
+        expect(remoteStartedAtSet.sql.replace(/\s+/g, " ")).toBe(
+          'case when "relay_live_activities"."activity_push_token" = excluded.activity_push_token then coalesce("relay_live_activities"."remote_started_at", excluded.remote_started_at) else excluded.remote_started_at end',
+        );
       }).pipe(
         Effect.provide(
           LiveActivities.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb))),

diff --git a/infra/relay/src/agentActivity/LiveActivities.ts b/infra/relay/src/agentActivity/LiveActivities.ts
--- a/infra/relay/src/agentActivity/LiveActivities.ts
+++ b/infra/relay/src/agentActivity/LiveActivities.ts
@@ -13,7 +13,7 @@
 import * as Function from "effect/Function";
 import * as Layer from "effect/Layer";
 import * as Schema from "effect/Schema";
-import { and, eq, sql } from "drizzle-orm";
+import { and, eq, ne, or, sql } from "drizzle-orm";
 
 import * as RelayDb from "../db.ts";
 import { relayLiveActivities, relayMobileDevices } from "../persistence/schema.ts";
@@ -141,6 +141,10 @@
         const updatedAt = DateTime.formatIso(yield* DateTime.now);
         const registration = input.registration;
 
+        // Claim the token from any OTHER row that still holds it (a token
+        // addresses exactly one activity). The device's own row is left
+        // untouched so the upsert below can tell a same-token re-registration
+        // apart from a fresh arm.
         yield* db
           .update(relayLiveActivities)
           .set({
@@ -150,7 +154,15 @@
             endedAt: updatedAt,
             updatedAt,
           })
-          .where(eq(relayLiveActivities.activityPushToken, registration.activityPushToken));
+          .where(
+            and(
+              eq(relayLiveActivities.activityPushToken, registration.activityPushToken),
+              or(
+                ne(relayLiveActivities.userId, input.userId),
+                ne(relayLiveActivities.deviceId, registration.deviceId),
+              ),
+            ),
+          );
 
         yield* db
           .insert(relayLiveActivities)
@@ -171,7 +183,17 @@
             set: {
               activityPushToken: registration.activityPushToken,
               remoteStartQueuedAt: null,
-              remoteStartedAt: updatedAt,
+              // remote_started_at records when the CARD was armed, and the
+              // end-on-empty grace window keys off it. Foreground
+              // reconciliation re-registers the same token periodically;
+              // refreshing the arming time there would perpetually renew the
+              // grace and suppress the end an orphaned card is waiting for.
+              // Only a new token (a new activity) restarts the clock.
+              remoteStartedAt: sql`case
+                when ${relayLiveActivities.activityPushToken} = excluded.activity_push_token
+                  then coalesce(${relayLiveActivities.remoteStartedAt}, excluded.remote_started_at)
+                else excluded.remote_started_at
+              end`,
               endedAt: null,
               lastAggregateJson: null,
               lastLiveActivityDeliveryAt: null,

You can send follow-ups to the cloud agent here.

Comment thread infra/relay/src/agentActivity/ApnsDeliveries.ts
The stuck-at-Working card: the thread's publish stream showed running →
deleted with no completed publish in between, and the tombstone debounce
correctly confirmed the null five seconds later — the post-completion
state resolves to no phase PERSISTENTLY. Session teardown settles
still-running turns by session status, and that write can race
turn.completed, leaving latestTurn.state at "interrupted" for a turn
that actually finished. The awareness ladder mapped interrupted turns to
null, so quick finish-then-teardown threads were tombstoned instead of
published as completed: the row vanished, the empty aggregate fell into
the freshly-armed grace window, and the card froze on its last state
with no further publish to correct it.

completedAt survives the state-column race: a turn with a completion
timestamp finished, whatever the settling wrote. The ladder now projects
those as completed — the tombstone confirm publishes Done instead of
deleting the thread — while genuinely interrupted turns (no completedAt)
still resolve to null.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
// that has a completion timestamp finished, whatever the state column says.
// Without this, quick finish-then-teardown threads resolve to null
// persistently and get tombstoned instead of published as completed.
if (thread.latestTurn?.state === "interrupted" && thread.latestTurn.completedAt !== null) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium src/agentAwareness.ts:112

The new fallback at line 112 misclassifies cancelled runs as completed. Turns settled via cancellation paths (interrupt-requested or session ended with interrupted/stopped status) also get state: "interrupted" with a non-null completedAt, so the condition thread.latestTurn?.state === "interrupted" && thread.latestTurn.completedAt !== null matches them too. This causes resolveThreadAwarenessPhase to return "completed" for runs the user actually cancelled, so users see stopped runs as successfully finished. If the intent is only to cover the teardown race where a completed turn's state is overwritten by interrupted, the guard needs to distinguish that case from genuine cancellations — for example, by checking the session status or a separate completion marker. Otherwise, consider documenting why interrupted turns with a completedAt should be treated as completed.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/shared/src/agentAwareness.ts around line 112:

The new fallback at line 112 misclassifies cancelled runs as `completed`. Turns settled via cancellation paths (interrupt-requested or session ended with `interrupted`/`stopped` status) also get `state: "interrupted"` with a non-null `completedAt`, so the condition `thread.latestTurn?.state === "interrupted" && thread.latestTurn.completedAt !== null` matches them too. This causes `resolveThreadAwarenessPhase` to return `"completed"` for runs the user actually cancelled, so users see stopped runs as successfully finished. If the intent is only to cover the teardown race where a `completed` turn's state is overwritten by `interrupted`, the guard needs to distinguish that case from genuine cancellations — for example, by checking the session status or a separate completion marker. Otherwise, consider documenting why interrupted turns with a `completedAt` should be treated as completed.

Comment thread packages/shared/src/agentAwareness.ts
The stuck-at-Working repro survives the completedAt ladder fix: the
confirmed tombstone five seconds after turn completion still resolves
null, so the settled shell holds some combination the static analysis
keeps missing. Instead of guessing another layer deep, the tombstone
paths now log the exact fields the phase ladder reads (session status,
latest turn id/state/completedAt, pendings) both when deferring and when
a confirmation actually publishes — one repro pins the writer.

Also collapses the deferral storm visible at thread birth (six confirm
fibers forked in 600ms): one pending confirmation per thread.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Pending confirm set can leak
    • Wrapped the check-add-fork sequence in a single uninterruptible region so a pendingTombstoneConfirms entry can only exist once the detached confirm fiber (whose ensuring finalizer owns the delete) is guaranteed to have been forked.

Create PR

Or push these changes by commenting:

@cursor push abb02eb729
Preview (abb02eb729)
diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts
--- a/apps/server/src/relay/AgentAwarenessRelay.ts
+++ b/apps/server/src/relay/AgentAwarenessRelay.ts
@@ -432,20 +432,30 @@
       // immediately deletes the thread from the lock-screen card mid-
       // conversation. Defer, re-resolve, and only tombstone if it holds;
       // genuinely deleted threads still propagate a few seconds later.
-      if (pendingTombstoneConfirms.has(threadId)) {
-        return;
-      }
-      pendingTombstoneConfirms.add(threadId);
-      yield* Effect.logInfo("agent activity tombstone deferred pending confirmation", {
-        environmentId,
-        threadId,
-        reason: snapshot.reason,
-        shell: describeThreadShellForAwareness(thread),
-      });
-      yield* Effect.forkDetach(
-        confirmTombstoneLater(threadId).pipe(
-          Effect.ensuring(Effect.sync(() => pendingTombstoneConfirms.delete(threadId))),
-        ),
+      // Check-add-fork must be atomic under interruption: a set entry without
+      // a forked confirm fiber (whose `ensuring` owns the delete) would
+      // suppress tombstone confirmation for this thread forever.
+      yield* Effect.uninterruptible(
+        Effect.suspend(() => {
+          if (pendingTombstoneConfirms.has(threadId)) {
+            return Effect.void;
+          }
+          pendingTombstoneConfirms.add(threadId);
+          return Effect.logInfo("agent activity tombstone deferred pending confirmation", {
+            environmentId,
+            threadId,
+            reason: snapshot.reason,
+            shell: describeThreadShellForAwareness(thread),
+          }).pipe(
+            Effect.andThen(
+              Effect.forkDetach(
+                confirmTombstoneLater(threadId).pipe(
+                  Effect.ensuring(Effect.sync(() => pendingTombstoneConfirms.delete(threadId))),
+                ),
+              ),
+            ),
+          );
+        }),
       );
       return;
     }

You can send follow-ups to the cloud agent here.

Comment thread apps/server/src/relay/AgentAwarenessRelay.ts Outdated
Diagnostics from the stuck-at-Working repro finally exposed the data
hole: the confirmed tombstone's shell showed sessionStatus "ready" with
latestTurn entirely absent. Threads whose turns produce no checkpoint
never get a projection_turns row, and thread.session-set clears
latest_turn_id the moment the session settles (activeTurnId goes null) —
so "completed" is unrepresentable through turns for quick Q&A threads.
Their entire lifecycle rode on session status and pendings, and at
completion nothing remained on the awareness ladder: tombstone instead
of Done.

A live session at "ready"/"idle" with nothing pending and nothing
running now projects as completed. This intentionally diverges from the
web sidebar, which resolves the same null but renders it as "no pill" —
a fine default in a list, while the publisher's null means "remove from
the lock-screen card". (The sidebar's own Completed pill needs
latestTurn.completedAt and silently can't fire for these threads either;
persisting turn rows for checkpoint-less turns is the deeper future fix
for both surfaces.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread packages/shared/src/agentAwareness.ts
The full lifecycle finally works end to end, with one wart: a duplicate
Done push notification fired one second after thread creation. Sessions
boot at "ready" before their first turn starts, so the new
ready-means-completed projection produced a momentary completed state at
birth — and since the freshly armed card's token wasn't registered yet,
it fell through to the notification channel. Server restarts had the
same shape at scale: the startup snapshot republished completed for
every recently finished thread, each queuing a push notification.

- Env server: completed as a thread's FIRST published state defers and
  re-resolves like a tombstone — at birth the confirm sees running and
  completed never publishes; genuinely at-rest threads still publish
  five seconds later. Fresh completions (previous state was live) are
  unaffected, keeping the buzz immediate.
- Relay: terminal push notifications only fire when the completion is
  fresh (row updated within two minutes), so replays and restart
  snapshots repaint state without ringing the device.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
if (!activity) {
return null;
}
if (activity.phase === "completed" || activity.phase === "failed") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium agentActivity/ApnsDeliveries.ts:321

notificationForAggregate drops terminal notifications for push-notification-only users when activity.updatedAt is more than two minutes old. activity.updatedAt is the timestamp from the published aggregate state, not the time the notification was first delivered to this device. If relay publishing or replay is delayed by more than two minutes (e.g., after downtime or a late reconnect), the first completed/failed push notification for a notifications-only user is silently suppressed even though it was never delivered before. Consider gating the freshness check on the device's last-known delivery time (e.g., last_push_notification_delivery_at) rather than activity.updatedAt, so only devices that already received a recent terminal notification are suppressed.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @infra/relay/src/agentActivity/ApnsDeliveries.ts around line 321:

`notificationForAggregate` drops terminal notifications for push-notification-only users when `activity.updatedAt` is more than two minutes old. `activity.updatedAt` is the timestamp from the published aggregate state, not the time the notification was first delivered to this device. If relay publishing or replay is delayed by more than two minutes (e.g., after downtime or a late reconnect), the first `completed`/`failed` push notification for a notifications-only user is silently suppressed even though it was never delivered before. Consider gating the freshness check on the device's last-known delivery time (e.g., `last_push_notification_delivery_at`) rather than `activity.updatedAt`, so only devices that already received a recent terminal notification are suppressed.

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: LA alerts skip terminal freshness
    • Threaded nowMs into alertForNewlyTerminal and alertForTerminalAggregate and applied the shared TERMINAL_NOTIFICATION_FRESHNESS_MS window via a new isFreshTerminalRow helper, so stale replayed completions no longer alert through Live Activity updates or end events.

Create PR

Or push these changes by commenting:

@cursor push c88cec1a49
Preview (c88cec1a49)
diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts
--- a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts
+++ b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts
@@ -1525,15 +1525,30 @@
       activities: [{ ...aggregate.activities[0]!, phase: "completed" as const, status: "Done" }],
     };
     expect(
-      ApnsDeliveries.alertForTerminalAggregate({ aggregate: terminalAggregate, preferences }),
+      ApnsDeliveries.alertForTerminalAggregate({
+        aggregate: terminalAggregate,
+        preferences,
+        nowMs: 0,
+      }),
     ).toEqual({ title: "Thread", body: "Done: Project" });
     expect(
       ApnsDeliveries.alertForTerminalAggregate({
         aggregate: terminalAggregate,
         preferences: { ...preferences, notifyOnCompletion: false },
+        nowMs: 0,
       }),
     ).toBeNull();
-    expect(ApnsDeliveries.alertForTerminalAggregate({ aggregate: null, preferences })).toBeNull();
+    expect(
+      ApnsDeliveries.alertForTerminalAggregate({ aggregate: null, preferences, nowMs: 0 }),
+    ).toBeNull();
+    // A completion replayed long after the fact must not ring again.
+    expect(
+      ApnsDeliveries.alertForTerminalAggregate({
+        aggregate: terminalAggregate,
+        preferences,
+        nowMs: 10 * 60 * 1_000,
+      }),
+    ).toBeNull();
   });
 
   it("alerts when a previously active thread finishes mid-flight", () => {
@@ -1552,6 +1567,7 @@
         previousAggregate: aggregate,
         nextAggregate: next,
         preferences,
+        nowMs: 0,
       }),
     ).toEqual({ title: "Thread", body: "Done: Project" });
     // The completion switch mutes it.
@@ -1560,6 +1576,7 @@
         previousAggregate: aggregate,
         nextAggregate: next,
         preferences: { ...preferences, notifyOnCompletion: false },
+        nowMs: 0,
       }),
     ).toBeNull();
     // No baseline means no transition to ring on.
@@ -1568,6 +1585,7 @@
         previousAggregate: null,
         nextAggregate: next,
         preferences,
+        nowMs: 0,
       }),
     ).toBeNull();
     // A Done row that was already terminal (or absent) before stays silent.
@@ -1576,6 +1594,7 @@
         previousAggregate: next,
         nextAggregate: next,
         preferences,
+        nowMs: 0,
       }),
     ).toBeNull();
     expect(
@@ -1583,7 +1602,18 @@
         previousAggregate: { ...aggregate, activities: [attentionRow] },
         nextAggregate: next,
         preferences,
+        nowMs: 0,
       }),
     ).toBeNull();
+    // A stale completion replayed after a restart (previous aggregate still
+    // shows the thread as running) must not ring the device again.
+    expect(
+      ApnsDeliveries.alertForNewlyTerminal({
+        previousAggregate: aggregate,
+        nextAggregate: next,
+        preferences,
+        nowMs: 10 * 60 * 1_000,
+      }),
+    ).toBeNull();
   });
 });

diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.ts b/infra/relay/src/agentActivity/ApnsDeliveries.ts
--- a/infra/relay/src/agentActivity/ApnsDeliveries.ts
+++ b/infra/relay/src/agentActivity/ApnsDeliveries.ts
@@ -210,6 +210,20 @@
   };
 }
 
+// Completions replayed long after the fact (server restarts republish every
+// recently-finished thread) must not ring the device again — on any channel:
+// push notifications, Live Activity update alerts, and end alerts all honor
+// the same freshness window.
+const TERMINAL_NOTIFICATION_FRESHNESS_MS = 2 * 60 * 1_000;
+
+function isFreshTerminalRow(row: { readonly updatedAt: string }, nowMs: number): boolean {
+  const updatedAtMs = Option.match(DateTime.make(row.updatedAt), {
+    onNone: () => null,
+    onSome: (dt) => dt.epochMilliseconds,
+  });
+  return updatedAtMs !== null && nowMs - updatedAtMs <= TERMINAL_NOTIFICATION_FRESHNESS_MS;
+}
+
 // Alert copy for an update whose aggregate contains threads that finished
 // (Done/Failed) since the previously delivered aggregate — the mid-flight
 // completion buzz while other agents keep the activity alive. Requires the
@@ -219,6 +233,7 @@
   readonly previousAggregate: RelayAgentActivityAggregateState | null;
   readonly nextAggregate: RelayAgentActivityAggregateState;
   readonly preferences: RelayAgentAwarenessPreferences | null;
+  readonly nowMs: number;
 }): ApnsLiveActivityAlert | null {
   if (input.previousAggregate === null) {
     return null;
@@ -230,6 +245,9 @@
     if (row.phase !== "completed" && row.phase !== "failed") {
       return false;
     }
+    if (!isFreshTerminalRow(row, input.nowMs)) {
+      return false;
+    }
     const previousPhase = previousPhases.get(row.threadId);
     return (
       previousPhase !== undefined &&
@@ -255,11 +273,15 @@
 export function alertForTerminalAggregate(input: {
   readonly aggregate: RelayAgentActivityAggregateState | null;
   readonly preferences: RelayAgentAwarenessPreferences | null;
+  readonly nowMs: number;
 }): ApnsLiveActivityAlert | null {
   const row = input.aggregate?.activities[0];
   if (!row || (row.phase !== "completed" && row.phase !== "failed")) {
     return null;
   }
+  if (!isFreshTerminalRow(row, input.nowMs)) {
+    return null;
+  }
   if (!alertAllowedForPhase(input.preferences, row.phase)) {
     return null;
   }
@@ -298,10 +320,6 @@
   );
 }
 
-// Completions replayed long after the fact (server restarts republish every
-// recently-finished thread) must not ring the device again.
-const TERMINAL_NOTIFICATION_FRESHNESS_MS = 2 * 60 * 1_000;
-
 function notificationForAggregate(input: {
   readonly target: LiveActivities.TargetRow;
   readonly aggregate: RelayAgentActivityAggregateState | null;
@@ -318,14 +336,11 @@
   if (!activity) {
     return null;
   }
-  if (activity.phase === "completed" || activity.phase === "failed") {
-    const updatedAtMs = Option.match(DateTime.make(activity.updatedAt), {
-      onNone: () => null,
-      onSome: (dt) => dt.epochMilliseconds,
-    });
-    if (updatedAtMs === null || input.nowMs - updatedAtMs > TERMINAL_NOTIFICATION_FRESHNESS_MS) {
-      return null;
-    }
+  if (
+    (activity.phase === "completed" || activity.phase === "failed") &&
+    !isFreshTerminalRow(activity, input.nowMs)
+  ) {
+    return null;
   }
   const enabled =
     (activity.phase === "waiting_for_approval" && preferences.notifyOnApproval) ||
@@ -419,6 +434,7 @@
             previousAggregate,
             nextAggregate,
             preferences,
+            nowMs: input.nowMs,
           }),
       }
     : "suppressed";
@@ -1100,6 +1116,7 @@
             : alertForTerminalAggregate({
                 aggregate: delivery.aggregate,
                 preferences: parsePreferences(input.target.preferences_json),
+                nowMs: input.nowMs,
               })
           : delivery.alert;
       const result = yield* deliveryQueue.enqueueLiveActivity({

You can send follow-ups to the cloud agent here.

Comment thread infra/relay/src/agentActivity/ApnsDeliveries.ts
…A pref gates

Fixes the real findings from the PR review bots ahead of the TestFlight
build:

- Deferred publish confirmations (tombstones and first-state
  completions) now re-enqueue through the same drainable worker as every
  other publish instead of a detached fiber calling the publisher
  directly, so a confirmed tombstone can never race an in-flight live
  update; a deadline map replaces the pending set, which also guarantees
  a single scheduled confirmation per thread and clears when the state
  recovers. (cursor/macroscope High)
- Newly-terminal transitions bypass the 15s live-activity update
  throttle: a completion landing in the same window as a new start keeps
  activeCount unchanged and would previously be suppressed, silently
  dropping the Done transition and its buzz. (macroscope High)
- The newly-terminal alert honors the same 2-minute freshness rule as
  terminal push notifications, so replayed aggregates repaint without
  ringing. (cursor Medium)
- Live Activity arming and priming treat an unset preference as enabled
  (the toggle's default): fresh installs now prime, and arm-on-send
  respects an explicit off. (cursor High/Medium)

Remaining findings triaged as stale (superseded designs), intended
behavior (replay painting recent Done, LA-disabled end retiring the
token), or accepted edge cases (grace extension on re-registration,
completion alerts beyond the 3-row display cap, ready-with-lastError
mapping to Done, cancelled turns showing Done).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Stale confirm deadline publishes tombstone
    • Cleared the thread's publishConfirmDeadlines entry in the unchanged-identity early-return path so a recovered projection cancels the deferred confirmation, preventing a later transient null state from confirming immediately against a stale expired deadline.

Create PR

Or push these changes by commenting:

@cursor push e02169b78e
Preview (e02169b78e)
diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts
--- a/apps/server/src/relay/AgentAwarenessRelay.ts
+++ b/apps/server/src/relay/AgentAwarenessRelay.ts
@@ -410,6 +410,11 @@
     const publishIdentity = agentAwarenessPublishIdentity(snapshot.state);
     const publishedStateByThread = yield* Ref.get(publishedStateByThreadRef);
     if (publishedStateByThread.get(threadId) === publishIdentity) {
+      // The projection recovered to the last published state, so any deferred
+      // confirmation (tombstone or first-state completion) is moot. Clear the
+      // deadline so the next divergence gets a fresh deferral window instead
+      // of confirming immediately against a stale, expired deadline.
+      publishConfirmDeadlines.delete(threadId);
       yield* Effect.logDebug("agent activity publish skipped; projected state unchanged", {
         environmentId,
         threadId,

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit 97ed442. Configure here.

Comment thread apps/server/src/relay/AgentAwarenessRelay.ts
…d state

A recovered projection exits at the unchanged-identity dedupe, which sits
above the confirmation block, so a deferred publish's deadline survived
recovery. A much later transient null would then find the deadline
already expired and publish its tombstone immediately, bypassing the
deferral window entirely. The dedupe path now clears any pending
deadline: the state is back at what was last published, so the deferred
confirmation is moot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@juliusmarminge juliusmarminge added the 🚀 Mobile Continuous Deployment Trigger Expo preview build label Jul 7, 2026
Fixes the Effect Service Conventions check: the service builds a plain
object with no effectful acquisition, so wrapping it in Effect.sync just
to feed Layer.effect was the make-only-to-force-Layer.effect shape the
conventions call out.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

🚀 Expo continuous deployment is ready!

  • Project → t3-code
  • Platforms → android, ios
  • Scheme → t3code-preview
  🤖 Android 🍎 iOS
Fingerprint 106610ae982be2d8aa28dda9217ff853daf9d30b 4f388ce49fd16563ef62f563f42c0528f092d42b
Build Details Build Permalink
DetailsDistribution: INTERNAL
Build profile: preview:dev
Runtime version: 106610ae982be2d8aa28dda9217ff853daf9d30b
App version: 0.1.0
Git commit: 74888084257320d42e7562c74996c949010bdf45
Build Permalink
DetailsDistribution: INTERNAL
Build profile: preview:dev
Runtime version: 4f388ce49fd16563ef62f563f42c0528f092d42b
App version: 0.1.0
Git commit: c622444d589c97adb591101ffca9161d785806b0
Update Details Update Permalink
DetailsBranch: pr-3685
Runtime version: 106610ae982be2d8aa28dda9217ff853daf9d30b
Git commit: 74888084257320d42e7562c74996c949010bdf45
Update Permalink
DetailsBranch: pr-3685
Runtime version: 4f388ce49fd16563ef62f563f42c0528f092d42b
Git commit: 74888084257320d42e7562c74996c949010bdf45
Update QR

@juliusmarminge juliusmarminge merged commit d8e0ba3 into main Jul 7, 2026
16 checks passed
@juliusmarminge juliusmarminge deleted the t3code/live-activities-improvements branch July 7, 2026 07:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🚀 Mobile Continuous Deployment Trigger Expo preview build size:XXL 1,000+ changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant